iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0

前言

週末了我卻還在這寫文章,真你他...嘿!歡迎回來!我絕對沒有在偷偷抱怨,昨天的 Day 4 我們成功地在本地建立了一個,呃,我們就先叫他資料庫吧!雖然不是很正式就是了,讓我們的頁面不再使用硬編碼而是整合一個由我們管理的題庫。現在我們的應用程式每次刷新都能看到不一樣的題目,總算有點樣子了,當然,我知道頁面還很醜、 API 回應還超慢,這我都知道,但別著急,一切都會好起來的。

不過,昨天如果你真的有做多次的測試,你應該會發現一個有些尷尬的的問題,當題目是一個程式實作題目時,例如:「實作一個 flatten 函數」這種程式題時,我們的作答區只是一個陽春的 ,場面瞬間尷尬起來。雖然實際面試時也是有考官會讓你直接在word文件之類的地方寫程式碼,但少了縮排、自動完成跟各種提示總覺得寫起來綁手綁腳的對吧!我們今天的目標就是讓程式題目的體驗不要再這麼悲催,給一個足夠規格的程式碼編輯界面讓人使用!

今日目標

  • 理解為什麼選擇 Monaco Editor 而不是其他方案。
  • 安裝並在我們的專案中整合 @monaco-editor/react。
  • 實作條件渲染:根據題目是「概念題」或「實作題」,顯示不同的編輯器。
  • 綁定編輯器狀態:確保我們能正確地從 Monaco Editor 中取得使用者輸入的程式碼。

為什麼是 Monaco Editor?

市面上有許多優秀的網頁程式碼編輯器,例如 CodeMirror、simple code editor之類的成熟套件。但考量到以下的優點,它確實會是我們專案相當適配的一個選項:

  • Monaco Editor 是驅動 VS Code 的核心元件。它提供了與 VS Code 幾乎完全一致的編輯體驗。
  • 使用者(前端工程師)幾乎零學習成本,一看到介面就知道怎麼用,能立刻專注在解題上。
  • 強大的 IntelliSense,擁有相當優秀的程式碼自動完成、型別提示、參數資訊等功能。
  • 生態系統成熟,多數的開發框架都有提供簡易整合的套件。

尤其在我們選用 Next.js 作為開發框架的前提下,Monaco Editor 配套的 @monaco-editor/react 在使用上極為容易,替我們處理完許多繁複的邏輯,在程式碼中可以極快看到不錯的效果,雖然也伴隨著一些效能上的隱憂(Monaco Editor 本身不算是個非常瘦身的套件),但權衡下優點還是遠大於缺點。了解這些後我們就可以開始今天實際的操作囉!

Step 1: 安裝 Monaco Editor for React

要在 React 專案中使用 Monaco Editor,最簡單的方式就是使用社群維護的 wrapper 套件。這讓我們可以像操作普通 React 元件一樣去使用它,也就是我們剛剛提到的@monaco-editor/react

在你的專案終端機中,執行以下指令:

npm install @monaco-editor/react

Step 2: 智慧作答區——實作條件渲染

現在最關鍵的一步來了。我們不希望所有題目都用 Monaco Editor,概念題用原本的 就足夠了。我們需要讓作答區「變聰明」,根據題目類型顯示對應的元件。

打開 app/page.tsx,我們來進行一些改造吧!這兩天我們都有對這個檔案做一些更動,若你真的嫌麻煩的話,可以直接複製以下的完整內容並貼上,一樣能在畫面中看到最新的進度,想確認今天新增的部分可以參考註解或是下方的補充說明。

'use client'; // 重要!告訴 Next.js 這是客戶端元件, 這樣才能使用 client-side 的 hooks

import { useState, useEffect } from 'react';
import { Question } from './types/question';
import Editor from '@monaco-editor/react'; // 引入 Editor 元件

export default function Home() {
  const [answer, setAnswer] = useState('');
  const [feedback, setFeedback] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
  const [isFetchingQuestion, setIsFetchingQuestion] = useState(false);

  useEffect(() => {
    const fetchQuestion = async () => {
      try {
        setIsFetchingQuestion(true);
        const response = await fetch('/api/questions');
        const data = await response.json();
        setCurrentQuestion(data);
        // 如果是程式題,設定初始程式碼
        if (data.type === 'code' && data.starterCode) {
          setAnswer(data.starterCode);
        } else {
          setAnswer('');
        }
      } catch (error) {
        console.error('無法抓取題目:', error);
      } finally {
        setIsFetchingQuestion(false);
      }
    };
    fetchQuestion();
  }, []);

  const handleSubmit = async () => {
    if (!answer) return;
    try {
      setIsLoading(true);

      // 發送請求到我們剛剛在後端app/api/gemini/route.ts建立的API
      const response = await fetch('/api/gemini', {
        method: 'POST',
        body: JSON.stringify({
          question: currentQuestion?.question,
          answer,
        }),
      });
      const data = await response.json();

      setFeedback(data.result);
    } catch (error) {
      console.error('錯誤:', error);
    } finally {
      setIsLoading(false);
    }
  };

  const renderAnswerArea = () => {
    if (!currentQuestion) return null;

    if (currentQuestion.type === 'code') {
      return (
        <Editor
          height="40vh"
          language="javascript"
          theme="vs-dark"
          value={answer}
          onChange={(value) => setAnswer(value || '')}
          options={{
            minimap: { enabled: false },
            fontSize: 16,
          }}
        />
      );
    } else {
      return (
        <textarea
          value={answer}
          onChange={(e) => setAnswer(e.target.value)}
          className="w-full h-32 bg-gray-700 rounded p-3 text-white"
          placeholder="在這裡輸入你的答案..."
          disabled={isLoading}
        />
      );
    }
  };

  return (
    <main className="min-h-screen bg-gray-900 text-white">
      <div className="container mx-auto px-4 py-16">
        <h1 className="text-4xl font-bold text-center mb-8">
          AI 前端面試官 🤖
        </h1>

        <div className="max-w-2xl mx-auto">
          {/* 題目區 */}
          <div className="bg-gray-800 rounded-lg p-6 mb-6">
            {isFetchingQuestion ? (
              <p className="text-center text-gray-400">正在從題庫抽取題目...</p>
            ) : (
              currentQuestion && (
                <>
                  <div className="text-sm text-blue-400 mb-2">
                    {currentQuestion.topic} 題目
                  </div>
                  <p className="text-lg">{currentQuestion.question}</p>
                </>
              )
            )}
          </div>

          {/* 作答區 */}
          <div className="bg-gray-800 rounded-lg p-6 mb-6">
            {renderAnswerArea()}
          </div>

          {/* 按鈕 */}
          <button
            onClick={handleSubmit}
            disabled={isLoading || !answer}
            className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition-colors"
          >
            {isLoading ? '🤔 AI 思考中...' : '提交答案'}
          </button>

          {/* AI 回饋區 */}
          <div className="bg-gray-800 rounded-lg p-6 mt-6">
            <div className="text-sm text-green-400 mb-2">AI 回饋</div>

            {feedback ? (
              <div className="text-gray-300 whitespace-pre-wrap">
                {feedback}
              </div>
            ) : (
              <p className="text-gray-400 italic">
                提交答案後,AI 將在這裡提供回饋...
              </p>
            )}
          </div>
        </div>
      </div>
    </main>
  );
}

主要改動說明:

  • 引入 Editor:我們從 @monaco-editor/react 引入了 Editor 元件,並加入幾個基本的 props 來讓編輯器做一些客製化,例如 height、language、theme,並在 options 中設置字體的大小讓體驗更舒適一點。
  • renderAnswerArea 函式:我們建立了一個新的函式專門用來處理作答區的渲染邏輯。它會檢查 currentQuestion.type,如果是 code,就渲染 元件;否則,就渲染之前的 讓使用者依然可以輸入概念問題回答。
  • 預設程式碼:我們在 useEffect 中加入判斷,如果抽到的是程式題,就自動將 starterCode 填入作答區。
  • 新增狀態綁定,我們使用 value={answer}onChange={(value) => setAnswer(value || '')} 來雙向綁定 state。onChange 會將使用者輸入在編輯器的內容回傳給編輯器,之後我們就可以將使用者輸入的程式碼送出或執行。
    狀態綁定:

完成到這個步驟後,再次輸入

npm run dev

進入localhost:3000 看一下畫面,反覆重新整理直到你刷到程式題目出現,如下圖1的畫面:

圖1
圖1 :程式碼編輯器呈現

稍微把弄一下,你會發現程式碼提示、語法高亮等基本功能全部都有!就好像你一般在開發一樣自然!一切就是這麼的簡單!當然,實際上畫面還是很簡陋,但這些我們都會在 Day7 的整合中做畫面上的優化,現在你只要知道我們有編輯器用就行囉!

Step 3: 驗證我們的成果

不過只有畫面是遠遠不夠的,在我們結束今天的內容之前,我們還得做最後一個確認:「我們是否真的能取得使用者輸入的程式碼」,在 Monaco Editor 取得編輯器內容的方式相當簡單,而且我們其實已經做出來了,也就是剛剛提到的雙向綁定,我們在使用者輸入程式碼的同時透過onChange更新answer變數,所以其實你只要回頭修改 handleSubmit 函式,在最前面加上一行 console.log

  const handleSubmit = async () => {
    if (!answer || !currentQuestion) return;

    // 驗證步驟:在送出前印出目前的答案
    console.log("準備提交的答案:", answer);

    try {
      setIsLoading(true);
      // ... 後續 fetch 程式碼不變
    }
    //...
  };

隨意在編輯器輸入任何程式碼後按下送出,你會發現輸入的程式碼確實被印出了。

圖2
圖2 :印出使用者輸入的程式碼

今日回顧

今天就這樣啦! 是不是覺得沒寫什麼東西但整個應用程式突然升級了不少? 很多時候其實就是這樣的,選好適合的工具後剩餘的東西都遠沒有這麼複雜,透過這個簡單的整合我們又離完整版更近一步了!再次回顧一下我們今天的進度吧:

✅ 了解了選擇 Monaco Editor 的理由
✅ 成功整合了 @monaco-editor/react
✅ 實作了根據題型動態切換作答區的智慧邏輯
✅ 確保了不論何種題型,都能正確地管理與提交答案

行有餘力的話你也可以稍微多玩玩今天用的套件,試著傳入不同的 props去做你理想中的客製化,可以在這邊看到完整提供的 props 。

明天預告

現在我們的「面子」已經沒有這麼悲催了,但「裡子」——AI 的智慧,還停留在 Day 2 的水準。明天(Day 6),我們要來鑽研整個 AI 應用的靈魂:Prompt Engineering!我們將學習來自 Google、OpenAI、Anthropic 的官方技巧,對我們的 AI「劇本」進行第一次大升級,讓它的回饋變得更精準、更可靠!

我們明天見!🚀

今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-5


上一篇
設計題庫系統:JSON是個好幫手
下一篇
讓 AI 更聰明:Prompt Engineering 初探
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言